16  Introduction to Leaflet

“Leaflet” is one of the most popular open-source JavaScript libraries for interactive maps.”

leaflet() documentation sites:

install.packages("leaflet")
# to install the development version from Github, run
# devtools::install_github("rstudio/leaflet")

From Static to Interactive: ggplot() vs. leaflet()

In the previous sections, we relied on the power of ggplot2’s integration with the sf package via ggplot() + geom_sf(..).

Conceptually, how you build maps in ggplot2 translates very well to leaflet, though there are key differences we will discuss.

In a nutshell, building maps in leaflets requires you to be much more explicit in everything you build in comparison to ggplot that handles a lot of logic automatically within its defaults.

Layering Logic

In ggplot2, you build your map by adding layers on top of one another. You might start with a base layer, add your geom_sf() polygons, and then perhaps add some points or text on top.

Leaflet conceptually works the same way:

  • The first object you add is the “canvas” (the bottom layer, often a basemap)

  • Subsequent objects (polygons, lines, points, annotations) are added on top of the previous ones

  • The order of your code determines the order of the drawing.

The Syntax Shift: Operator

The primary syntactic difference lies in how we bind these layers together.

  • In ggplot2: We use the plus sign (+) to add layers to a plot object.

  • In leaflet: We use the pipe operator (%>% or |>) to chain functions together.

Think of the pipe in Leaflet exactly as you use it in dplyr for data manipulation:

  • you are passing your map object into the next function to add a new feature.

Coordinate Reference Systems (CRS)

In the prior sections on building static maps, we stressed the importance of choosing the “correct” CRS (e.g., using an Albers Equal Area projection to ensure that counties in the north aren’t visually distorted compared to those in the south). In ggplot2, you have total control over this projection.

Leaflet is different. Because Leaflet is built for web mapping (like Google Maps or OpenStreetMap), it relies on a standardized tiling system.

  • Display: Leaflet effectively forces the map display into Web Mercator (EPSG: 3857). You generally do not change this.

  • Inputs: Leaflet expects your data objects (i.e. your shapefiles) to be in WGS84 (EPSG: 4326)

While the leaflet package in R is smart enough to attempt to automatically re-project your sf data on the fly, mapping in leaflet will work better for you if you transform everything to WGS84 before feeding it into the map.

Color Scales and Legends are Handled Differently

Color Scales

In ggplot2, mapping a variable to color is as simple as aes(fill = value_variable). ggplot handles the logic of converting quantitative numbers to a gradient color scale automatically, or users can utilize a function like scale_fill_manual() to further customize the palette used.

leaflet() requires users to build color scales and mapping manually. The best way I’ve found to do this is to create a “palette function” that translates a domain of numbers into a range of colors.

Legends

Similar to color scale handling, ggplot() automatically handles the breaks, labels, and positioning of the legend for you. You can also use the behavior of adding custom + theme(...) elements to tailor the text size, color, and positioning.

leaflet() treats legends as completely independent objects. Because the map layers and the legend are separate HTML elements, they do not “talk” to each other.

  • You must explicitly add the legend using %>% addLegend(...).

  • You have to tell the legend again which palette function and which data values to use. If you change the palette for your polygons but forget to update the legend, they will mismatch.

  • The legend floats on top of the map. You can set the corner you want it placed in, but it does not draw outside of the map – it covers and float on top of whatever is underneath it. You can use custom javascript and .css styling to customize it, but leaflet’s base arguments for addLegend() do not natively support adjusting text size, color, etc.

Data Preparation: U.S. Census Data

For the next sections, we will use a simple subset of U.S. Census Data (ACS 5-year survey estimates) for the total population of each U.S. state and county.

tidycensus to get the population estimates:

us_cnty_pops <- get_acs(
  geography = "county",
  variables = "B01003_001",
  year = 2023,
  survey = "acs5"
)

us_st_pops <- get_acs(
  geography = "state",
  variables = "B01003_001",
  year = 2023,
  survey = "acs5"
)

tigris to get the county and state polygons:

##--for the purpose of simplifying this demo, we'll exclude non-contiguous polygons...
non_contiguous_fips <- c("02", "15", "72", "66", "78", "60", "69")

us_cnty_sf <- counties(cb = TRUE, resolution = "20m", progress_bar = FALSE) %>%
  filter(!STATEFP %in% non_contiguous_fips) %>%
  select(GEOID)

us_st_sf <- states(cb = TRUE, resolution = "20m", progress_bar = FALSE) %>%
  filter(!STATEFP %in% non_contiguous_fips) %>%
  select(GEOID)

Then we can join the attributes to the geometry using the “GEOID” field.

us_cnty_joined <- us_cnty_sf %>%
  left_join(us_cnty_pops, by = "GEOID")

us_st_joined <- us_st_sf %>%
  left_join(us_st_pops, by = "GEOID")

1. Simple Map Build

Show the code
# Static map using ggplot() + goem_sf()
ggp1 <- ggplot() +    
  geom_sf(data = us_st_joined, aes(fill = estimate), color = "#f1f0ea") + 
  scale_fill_carto_c(
    palette = "Teal", 
    name = "Total Population"
  ) +
  theme_void()
Show the code
# Interactive map using leaflet()

##-- set CRS to WGS94
us_st_leaf <- st_transform(us_st_joined, crs = 4326)
us_cnty_leaf <- st_transform(us_cnty_joined, crs = 4326)

##-- map color scale for polygon fill
pop_pal_1 <- colorNumeric(
  palette = rcartocolor::carto_pal(n = 7, name = "Teal"), 
  domain = us_st_joined$estimate
)

##-- simple leaflet() map call: 
leaf1 <- leaflet(data = us_st_leaf) %>% 
  addTiles() %>%
  addPolygons(                          
    fillColor = ~pop_pal_1(estimate),
    color = "#f1f0ea",
    weight = 0.3,
    fillOpacity = 1,
    opacity = 0.5
  ) %>% 
  addLegend(
    pal = pop_pal_1,             
    values = ~estimate,          
    title = "Total Population",  
    opacity = 1,                 
    position = "bottomright"     
  )

The power of leaflet()

The following sections give a brief overview of the interactivity in leaflet that really starts to shine.

Basemaps (aka “Provider Tiles”)

In the first map, we used addTiles() that defaults to OpenStreetMap (OSM) tiles.

More often than not, this default is too colorful and too busy for epi-focussed maps. We want a quieter background that lets the data stand out.

The good news: there are dozens of free alternatives available.

The most straightforward list of options comes from the built-in providers list in R. You can see all available names by running names(providers) in your console, or by visiting the Leaflet Providers Preview site.

When building the map, addTiles() becomes: addProviderTiles(...)

Important note here: many map providers require you to register and get an API key or token that you need to bring in for it to work.

Providers like “OpenStreetMap”, “CartoDB,” and “Esri” do not require an API key, so will work seamlessly out-of-the-box without needing to take any additional steps.

For the most “bare-bones” and simple look:

  • Esri.WorldGrayCanvas: A very minimalist, grayscale background

  • CartoDB.Positron: Very similar to Esri’s Gray Canvas, but with a tad more detail added like major roadways and more detailed area labels for cities and towns.

    • CartoDB.PositronNoLabels: same as above but without labels

    • CartoDB.DarkMatter: dark mode

    • CartoDB.DarkMatterNoLabels: dark mode no labels

If references like roads or geogrpahic features are important:

  • Esri.OceanBasemap: basic detail of topography and geographic features with colors for forested areas, waterbodies, major roads and boundaries.

  • Esri.WorlTerrain: very simple shaded relief of topography

  • CartoDB.Voyager: if roads and major geographic markers are still helpful, but still less colorful and busy as the OpenStreetMap

    • CartoDB.VoyagerNoLabels: same-same, no labels
Show the code
##-- simple leaflet() map call: 
leaf_base <- leaflet(data = us_st_leaf) %>% 
  addPolygons(                          
    fillColor = ~pop_pal_1(estimate),
    color = "#f1f0ea",
    weight = 1.5,
    fillOpacity = 1,
    opacity = 0.6
  ) 

# Add basemaps
leaf_base_opts <- leaf_base %>%
  addProviderTiles(providers$Esri.WorldGrayCanvas, group = "Esri Gray") %>%
  addProviderTiles(providers$CartoDB.Positron,     group = "CartoDB Gray") %>%
  addProviderTiles(providers$CartoDB.DarkMatter,   group = "CartoDB Dark") %>%
  addProviderTiles(providers$CartoDB.Voyager,      group = "CartoDB Voyager") %>%
  addProviderTiles(providers$Esri.WorldImagery,    group = "Satellite") %>%
  
  addLayersControl(
      baseGroups = c(
        "Esri Gray", "CartoDB Gray", "CartoDB Dark", 
        "CartoDB Voyager", "Satellite"
        ),
      overlayGroups = c("County Population"),
      options = layersControlOptions(collapsed = FALSE) 
    )
  
  

leaf_base_opts

Hovers and Pop-ups

A map that reacts to your mouse just feels more “alive,” and highlightOptions() is a great way to get that feel.

I love using this argument because it gives the user immediate visual feedback, like the map is saying, “Yes, this is the specific county you are pointing at.” It is especially intuitive if you eventually pair your map with a Shiny app (where a click triggers a table update, for example), but honestly, even as a stand-alone feature, I think this hover-highlighting effect looks nice.

Customizing the Pop-ups

When it comes to the text labels themselves, labelOptions() allows you to pass most standard CSS styling arguments to the text box.

However, I’ve found that I’m rarely satisfied with just the default styling options.

To really control the look, like making the county name bold or adding a line break before the a value, we use a simple trick: combining paste0() with lapply() and htmltools::HTML.

Below is an example of how to combine highlightOptions() for the interaction, and labelOptions() (with some custom CSS for the label) for a much sexier tooltip.

Show the code
##-- simple leaflet() map call: 
leaf_labels <- leaflet(data = us_st_leaf) %>% 
  
  addProviderTiles(providers$Esri.WorldGrayCanvas, group = "Esri Gray") %>%
  
  addPolygons(                          
    fillColor = ~pop_pal_1(estimate),
    color = "#f1f0ea",
    weight = 1.5,
    fillOpacity = 0.8,
    opacity = 0.6,
      
  # --- highlights polygon on hover ---
    highlightOptions = highlightOptions(
      weight = 2,               
      color = "black",           
      fillOpacity = 1,        
      bringToFront = TRUE        
    ),
    
  # --- Pop-Up styling ---      
    label = ~lapply(
        paste0(
          "<b>", NAME, "</b><br>",
          "<i> Population:</i> ", 
          scales::comma(estimate, accuracy = 1)
        ), 
        htmltools::HTML
      ),
  
   # --- additional css styling options ---     
    labelOptions = labelOptions(
      style = list(
        "background-color" = "white",  
        "color" = "black",              
        "font-size" = "12pt",
        "padding" = "10px",
        "border" = "2px solid black",   
        "border-radius" = "5px",        
        "box-shadow" = "3px 3px 10px rgba(0,0,0,0.5)" 
      ),
      direction = "auto",
      noHide = FALSE 
    )
  ) 


leaf_labels

Controlling Layer Views

Controlling Layers with “groups” “panes” and “layer controls”

Sometimes, more is just… more.

We’ve all seen maps that try to do too much at once: layering points over polygons over lines until the result is a muddy and unreadable. As the map-maker, you shouldn’t have to force your user to choose between seeing the “State” level data or the “County” level data, or details like roads. You can give them the power to choose and select the layers they want to see, just like most Esri maps natively support.

In Leaflet, we can accomplish this with Panes, Groups and Layer Controls.

The first step happens inside your addPolygons() (or addMarkers) function. You need to assign a unique “group” name to that specific shapefile using the group argument.

Think of this like just tagging your shapefiles. You are telling Leaflet: “Okay, all these big shapes? They belong to Team ‘State Population’. And all these smaller shapes? They belong to Team ‘County Population’.”

Once your layers are tagged, you give the user a remote control to toggle layers with addLayersControl().

There are two types of controls you can add here:

  • baseGroups (Radio Buttons): These are mutually exclusive. Logic dictates you can only see one at a time (e.g., choosing between “Satellite” vs. “Street” background).

  • overlayGroups (Checkboxes): These are additive. You can stack as many as you want on top of each other.

By default, Leaflet turns everything on when the map loads. If you have overlapping layers (like States covering Counties), this can be problematic. Add a hideGroup() function at the very end of the map’s pipe chain to tell the map: “Load this layer in the background, but keep it invisible until the user checks the box.”

Where do “panes” come in?

If “Groups” are about tagging what is visible, “Panes” are about where it’s visible.

By default, Leaflet it stacks the shapefiles in the exact order you write them in your code (%>%). The last thing added gets plopped on top.

Once you add in the layer controls, this creates a problem: if a user toggles the “Counties” layer off and on, Leaflet effectively “re-adds” it to the map. Suddenly, your counties might reappear on top of your state borders, making the “State” layer invisible as it’s drawn behind it.

Map Panes solve this by creating permanent slots at specific heights.

  • We assign a Z-Index to each pane we want to control using addMapPane(). A higher number means it floats higher up toward the user’s eye.

  • Then we tell the shapefile: “No matter when you load, you always live in this_pane.

For example…

##-- even though the "counties_sf" is drawn second in 
###-- the pipe chain, because we mapped it to a "lower" z-index pane
####-- as the state boundaries, it will always be drawn underneath it

leaflet() %>% 
  
  addMapPane("higher_pane_name", zIndex = 460) %>%
  addMapPane("lower_pane_name",  zIndex = 450) %>%
  
  addPolygons(
    data = state_boundaries_sf,
    options = pathOptions(pane = "higher_pane_name")
    ...
  ) %>%
  
  addPolygons(
    data = counties_sf,
    options = pathOptions(pane = "lower_pane_name")
    ...
  ) %>%

Leaflet reserves specific Z-index levels for its standard parts to ensure the map always works (e.g., Pop-ups must always be on top, or they would be covered by the map shapes).

Here are the default Z-index scores you need to know if you want to enforce different panes:

Leaflet's Default Reserved Z-Index Panes
Level..z.index. Component Description
200 tilePane The Basemap. (Google Maps, OpenStreetMaps). This is the ground floor.
400 overlayPane Standard Polygons. If you don't specify a pane, your addPolygons go here.
500 shadowPane The little shadows under standard teardrop markers.
600 markerPane Icons & Markers. Points generally need to sit on top of polygons.
650 tooltipPane Hover labels.
700 popupPane Pop-ups. The click-to-read boxes. These must be on top of everything.

Putting it all together

Show the code
leaf_layer_select <- leaflet() %>% 
  
  addProviderTiles(providers$Esri.WorldGrayCanvas, group = "Esri Gray") %>%
  addProviderTiles(providers$CartoDB.Positron,     group = "CartoDB Gray") %>%
  addProviderTiles(providers$CartoDB.DarkMatter,   group = "CartoDB Dark") %>%
  addProviderTiles(providers$CartoDB.Voyager,      group = "CartoDB Voyager") %>%
  addProviderTiles(providers$Esri.WorldImagery,    group = "Satellite") %>%
  
  addMapPane("borders_pane", zIndex = 460) %>%
  addMapPane("cntys_pane",   zIndex = 450) %>%
  addMapPane("states_pane",  zIndex = 250) %>%


  addPolygons(
    data = us_st_leaf,
    fillColor = ~pop_pal_1(estimate),
    color = "black", 
    weight = 0.75,  
    fillOpacity = 0.9,
    options = pathOptions(pane = "states_pane"),
    group = "State Population",
    
    highlightOptions = highlightOptions(
      weight = 1.5,               
      color = "black",           
      fillOpacity = 1,
      opacity = 1,
      bringToFront = TRUE        
    ),
    
    label = ~lapply(
        paste0(
          "<b>", NAME, "</b><br>",
          "<i> Population:</i> ", 
          scales::comma(estimate, accuracy = 1)
        ), 
        htmltools::HTML
      ),    
    
    labelOptions = labelOptions(
      style = list(
        "background-color" = "white",  
        "color" = "black",              
        "font-size" = "12pt",
        "padding" = "10px",
        "border" = "2px solid black",   
        "border-radius" = "5px",        
        "box-shadow" = "3px 3px 10px rgba(0,0,0,0.5)" 
      ),
      direction = "auto",
      noHide = FALSE 
    )

  ) %>% 
  
  addPolygons(
    data = us_cnty_leaf,    
    fillColor = ~cnty_pop_pal(estimate),
    color = "black", 
    weight = 0.35, 
    opacity = 0.35,
    fillOpacity = 1,
    options = pathOptions(pane = "cntys_pane"),
    group = "County Population",
    
    highlightOptions = highlightOptions(
      weight = 0.65,               
      color = "black",           
      fillOpacity = 1,        
      bringToFront = TRUE        
    ),
    
    label = ~lapply(
        paste0(
          "<b>", NAME, "</b><br>",
          "<i> Population:</i> ", 
          scales::comma(estimate, accuracy = 1)
        ), 
        htmltools::HTML
      ),    
    
    labelOptions = labelOptions(
      style = list(
        "background-color" = "white",  
        "color" = "black",              
        "font-size" = "10pt",
        "padding" = "10px",
        "border" = "2px solid black",   
        "border-radius" = "5px",        
        "box-shadow" = "3px 3px 10px rgba(0,0,0,0.5)" 
      ),
      direction = "auto",
      noHide = FALSE 
    )
    
  ) %>% 
  
  
  addPolygons(
    data = us_st_leaf,
    fill = FALSE, 
    color = "black", 
    weight = 0.75, 
    opacity = 1,
    options = pathOptions(
      pane = "borders_pane", 
      interactive = FALSE  
    )
  ) %>%
  
  addLayersControl(
      baseGroups = c("Esri Gray", "CartoDB Gray", "CartoDB Dark", "CartoDB Voyager", "Satellite"),
      overlayGroups = c("State Population", "County Population"),
      options = layersControlOptions(collapsed = FALSE) 
    ) %>% 

  hideGroup(c("County Population"))



leaf_layer_select

Controlling Layers with Zoom

Show the code
show_cnty_js <- "
function(el, x) {
  var map = this;
  
  var statesLayer = map.layerManager.getLayerGroup('States');
  var countiesLayer = map.layerManager.getLayerGroup('Counties');
  
  if(countiesLayer) map.removeLayer(countiesLayer);
  
  function checkZoom() {
    var z = map.getZoom();
    
    if (z > 5) {
      if(statesLayer) map.removeLayer(statesLayer);
      if(countiesLayer) map.addLayer(countiesLayer);
    } else {
      if(countiesLayer) map.removeLayer(countiesLayer);
      if(statesLayer) map.addLayer(statesLayer);
    }
  }
  
  map.on('zoomend', checkZoom);
  
  if(statesLayer) {
    statesLayer.eachLayer(function(layer) {
      layer.on('click', function(e) {
        // Zoom to the bounds of the clicked state
        map.fitBounds(e.target.getBounds());
      });
    });
  }
}
"



leaf_cnty_zoom <- leaflet() %>% 
  addProviderTiles(providers$Esri.WorldGrayCanvas) %>% 
  
    addMapPane("cntys_pane",  zIndex = 450) %>%
    addMapPane("states_pane", zIndex = 250) %>%
    addMapPane("borders_pane", zIndex = 460) %>%
  

  addPolygons(
    data = us_st_leaf,
    fillColor = ~pop_pal_1(estimate),
    group = "States",       
    color = "black", 
    weight = 1, 
    fillOpacity = 0.9,
    options = pathOptions(pane = "states_pane"),
    
    highlightOptions = highlightOptions(
      weight = 1.5,               
      color = "black",           
      fillOpacity = 1,
      opacity = 1,
      bringToFront = TRUE        
    ),
    
    label = ~lapply(
        paste0(
          "<b>", NAME, "</b><br>",
          "<i> Population:</i> ", 
          scales::comma(estimate, accuracy = 1)
        ), 
        htmltools::HTML
      ),    
    
    labelOptions = labelOptions(
      style = list(
        "background-color" = "white",  
        "color" = "black",              
        "font-size" = "12pt",
        "padding" = "10px",
        "border" = "2px solid black",   
        "border-radius" = "5px",        
        "box-shadow" = "3px 3px 10px rgba(0,0,0,0.5)" 
      ),
      direction = "auto",
      noHide = FALSE 
    )

  ) %>% 
  
  addPolygons(
    data = us_cnty_leaf,    
    group = "Counties",     
    fillColor = ~cnty_pop_pal(estimate),
    color = "white", 
    weight = 0.5, 
    fillOpacity = 1,
    options = pathOptions(pane = "cntys_pane"),
    
    highlightOptions = highlightOptions(
      weight = 1,               
      color = "black",           
      fillOpacity = 1,        
      bringToFront = TRUE        
    ),
    
    label = ~lapply(
        paste0(
          "<b>", NAME, "</b><br>",
          "<i> Population:</i> ", 
          scales::comma(estimate, accuracy = 1)
        ), 
        htmltools::HTML
      ),    
    
    labelOptions = labelOptions(
      style = list(
        "background-color" = "white",  
        "color" = "black",              
        "font-size" = "10pt",
        "padding" = "10px",
        "border" = "2px solid black",   
        "border-radius" = "5px",        
        "box-shadow" = "3px 3px 10px rgba(0,0,0,0.5)" 
      ),
      direction = "auto",
      noHide = FALSE 
    )
    
  ) %>% 
  
  
  addPolygons(
    data = us_st_leaf,
    fill = FALSE, 
    color = "black", 
    weight = 1, 
    opacity = 1,
    options = pathOptions(
      pane = "borders_pane", 
      interactive = FALSE  
    )
  ) %>%
  
  onRender(show_cnty_js)



leaf_cnty_zoom

As we learned earlier, choropleth maps are often not the best choice. So we can use varying point sizes instead by abstracting the polygon’s central points.